TT-17841: improved tests for persistent storage - #158
Conversation
|
CLA Assistant Lite bot: I have read the CLA Document and I hereby sign the CLA 1 out of 3 committers have signed the CLA. |
|
This pull request introduces a driver-agnostic conformance test suite for the persistent storage layer to ensure consistent behavior across different database implementations. The new test suite uncovered several critical correctness bugs in the PostgreSQL driver, which have been fixed. The changes include making Files Changed Analysis
Architecture & Impact Assessment
graph TD |
Security Issues (2)
Performance Issues (1)
Security Issues (2)
Performance Issues (1)
Powered by Visor from Probelabs Last updated: 2026-08-10T22:01:04.662Z | Triggered by: pr_updated | Commit: c2fb84c 💡 TIP: You can chat with Visor using |
…gainst a real Postgres 16.10 under the postgres16.10 tag; Mongo conformance passes under mongo7.0. Not touched: the pre-existing gofumpt nit in storage.go (outside these findings).
Address Visor review: upsertLockKey's fmt.Sprintf("%v") fallback for
non-JSON-serializable query values is not canonical, so distinct queries
could collide on the advisory-lock key (false contention / DoS on
attacker-controlled input) or the same query could hash differently
(missed lock, reintroducing the upsert race). Return an error instead of
the ambiguous fallback and propagate it from Upsert, making the lock key
fully deterministic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…okup
Apply /simplify cleanups from PR review:
- Upsert: replace hand-rolled tx.Begin()/recover-defer/8x Rollback/Commit
with d.db.Transaction(func(tx) error {...}), which auto-rolls-back on any
returned error or panic and auto-commits otherwise. Behavior-preserving
(advisory lock is transaction-scoped either way); removes the maintenance
hazard of a forgotten Rollback on a future edit.
- GetIndexes: skip the index_metadata existence check and TTL query when
there are no secondary indexes to annotate, avoiding two DB round-trips on
the common no-secondary-index path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|



Description
Add a driver-agnostic conformance test suite for the persistent storage layer, and fix the driver bugs that suite uncovered (mostly PostgreSQL, plus a Mongo/mgo upsert-concurrency fix).
The
persistentpackage supports multiple database drivers (mgo, official MongoDB, PostgreSQL) behind a singlePersistentStorageinterface, but there was no shared way to guarantee every driver behaves identically against that contract. Each driver had its own ad-hoc tests, so behavioral drift between MongoDB and PostgreSQL went undetected.This PR introduces a contract-based conformance test suite (
persistent/internal/testutil/suite.go) that runs the same behavioral assertions against every driver (conformance_mgo_test.go,conformance_mongo_test.go,conformance_postgres_test.go). Running the suite surfaced several correctness bugs, which are fixed here.What we're doing
1. Conformance test framework
Suite+RunSuiteharness validating any driver against thePersistentStorageinterface (Ping, HasTable, Migrate/Drop, CRUD, Update, Upsert, query translation, indexes).2. PostgreSQL driver fixes
Updatenever inserts a ghost row:Updatenow issues a single all-fieldsUPDATEviaSelect("*").Omit("id").Updates(object)instead of GORM's upsert-flavoredSave. BecauseUpdatesnever falls back toINSERTwhen theWHEREmatches nothing,RowsAffected == 0is a reliable signal that the record is missing (returningsql.ErrNoRows), and there is no TOCTOU window: a concurrentDELETEcan no longer let the write resurrect the row, and an object with a zero/mismatched ID can no longer create a duplicate. This also removes the previous pre-COUNT+ explicit transaction entirely.Upsert:Upsertacquires apg_advisory_xact_lock(keyed on table + query) to serialize concurrent upserts of the same logical record and prevent duplicate inserts. Existence is determined viaCOUNTrather thanRowsAffected, so an upsert with an empty update map no longer wrongly falls through toINSERTfor an existing record. The transaction is managed with GORM'sdb.Transaction(...)(auto-rollback on error/panic, auto-commit otherwise). The advisory-lock key is derived by JSON-marshaling the sorted query values; a value that is not JSON-serializable is now rejected with an error rather than hashed via an ambiguousfmt.Sprintffallback, so the key is always canonical (this closes a lock-key collision / false-contention vector flagged in review).$orquery translation: multi-field conditions inside a single$orclause are now correctly grouped withANDin a nested sub-expression (previously they were flattened, producing incorrect boolean logic). Nested field names also get the same.→_conversion and identifier sanitization as the non-$orpath.GetIndexesreadsindex_metadatato correctly flagIsTTLIndexand populateTTL. A missingindex_metadatatable (it is only created with the first TTL index) is treated as "no TTL metadata", but any other query error is surfaced instead of silently swallowed. The lookup is skipped entirely when the table has no secondary indexes to annotate, avoiding two needless round-trips on the common path.3. Mongo / mgo driver fix
Upsert: both the official Mongo and mgo drivers now retry (bounded) on a duplicate-key error from the upsert insert race. Servers before 5.0 do not retry thefindAndModify(upsert:true)insert path internally, so concurrent upserts of the same not-yet-existing_idcould return a transientE11000to the caller; the losing call now re-reads the winner's document. This is required for the sharedUpsertNoDuplicatesUnderConcurrencyconformance assertion to hold on Mongo 4.2/4.4.4. CI / build tooling
postgres_test_dsnmatching the containerized Postgres credentials so the Postgres conformance tests run.postgres16.10/postgres16.1,postgres15.0/postgres15,mongo7.0/mongo7,mongo6.0/mongo6) so the suites compile and run on every matrix row regardless of how the version token is rendered.-coverpkgacross the storage tree and only iterates packages that actually have tests under the active build tag; empty-line coverage files (Go 1.25 +-coverpkg) are stripped beforegocovmergeto prevent merge failures.internal/testutil.Related Issue
https://tyktech.atlassian.net/browse/TT-17841
Motivation and Context
There was no shared contract test guaranteeing consistent behavior across the persistent drivers, allowing behavioral drift (especially Postgres vs Mongo) to go unnoticed. Building the conformance suite exposed real correctness bugs around
Updateatomicity,Upsertconcurrency (Postgres and Mongo),$orquery logic, and TTL index reporting — all fixed here.Acceptance Criteria
Updateon a non-existent record returnssql.ErrNoRowsand never creates a new row.Updatecannot produce a ghost insert under a concurrent delete (single atomicUPDATEthat never falls back toINSERT).Upsertcalls for the same query do not create duplicate records (Postgres advisory lock; Mongo/mgo duplicate-key retry).Upsertwith an empty update map on an existing record updates/returns that record rather than inserting a duplicate.$orquery with multiple fields per clause produces correct(a AND b) OR (c AND d)semantics, with proper field-name sanitization.GetIndexescorrectly reportsIsTTLIndexandTTLfor TTL indexes, and does not error when no TTL metadata table exists.gocovmergefailures) and the newtestutilcode is included in Sonar analysis.make lint(gofumpt + golangci-lint) and the full test matrix pass.Test Coverage For This Change
task test-persistent DB=postgres DB_VERSION=16.10,DB=mongo DB_VERSION=7.0, and the mgo variant — all run the conformance suite (with-race).basic_operations_test.go,query_test.gocovering the Update/Upsert/$orfixes.-raceto confirm no data races and no duplicate rows under concurrent Update/Upsert.task merge-coverageproduces a validmerged-coverage.cov.Notes / Trade-offs
Updateis now a single all-fieldsUPDATE(no pre-COUNT, no explicit transaction).Upsertkeeps one extraCOUNTround-trip inside its advisory-lock transaction to correctly handle an empty update map.pg_advisory_xact_lockin the PostgresUpsertserializes concurrent upserts that use the same (table, query) pair (intentional, for correctness). It does not guard upserts reaching the same row via a different filter, nor writers that bypassUpsert(Insert, raw SQL); uniqueness beyond the primary key is not enforced at the schema level.Types of changes
Checklist
master!masterbranch (left side). Also, it would be best if you started your change off our latestmaster.go mod tidy && go mod vendorgofmt -s -w .go vet ./...Ticket Details
TT-17841
Generated at: 2026-08-10 22:03:58